feat(webview): display file diffs for tool call results#83
Conversation
Add visual diff rendering for file edit operations in the chat view. When agents modify files, users now see a clear before/after comparison with syntax highlighting for additions and deletions. Implementation: - Add computeLineDiff() for simple line-by-line diff algorithm - Add renderDiff() to generate HTML with diff styling - Integrate diff rendering into toolCallComplete handler - Add CSS styles for diff visualization (green additions, red deletions) - Handle edge cases: empty files, new files, large diffs (truncated at 500 lines) Tests: - Add 10 unit tests for diff computation and rendering - Test HTML escaping, truncation, and edge cases Closes #43
📊 Coverage ReportLines: 33.2% (1014/3054) |
There was a problem hiding this comment.
Pull request overview
This PR adds visual diff rendering for file edit operations in the chat view. When AI agents modify files through tool calls, users will see before/after comparisons with syntax-highlighted additions and deletions.
Changes:
- Implements
computeLineDiff()for line-by-line diff computation andrenderDiff()for HTML generation - Integrates diff rendering into the
toolCallCompletemessage handler - Adds CSS styling using VS Code theme variables for diff visualization
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| src/views/webview/main.ts | Adds DiffLine interface, computeLineDiff() and renderDiff() functions, and integrates diff rendering in toolCallComplete handler |
| src/test/webview.test.ts | Adds 10 unit tests covering diff computation, HTML rendering, escaping, and truncation |
| media/main.css | Adds CSS styles for diff container, headers, added/removed lines, and truncation notices |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!oldText && !newText) { | ||
| return []; | ||
| } | ||
| if (!oldText) { | ||
| // New file - all lines are additions | ||
| return newText!.split("\n").map((line) => ({ type: "add", line })); | ||
| } | ||
| if (!newText) { |
There was a problem hiding this comment.
The implementation uses truthiness checks (!oldText, !newText) rather than explicit null/undefined checks. This causes empty strings to be treated the same as null/undefined, which is semantically incorrect:
- Empty file ("") vs null (non-existent file) have different meanings
- computeLineDiff("", "hello") incorrectly treats this as creating a new file, when it should show adding "hello" to an existing empty file
Change the checks to be explicit:
if (oldText === null || oldText === undefined) {
// New file case
}
if (newText === null || newText === undefined) {
// Deleted file case
}This also aligns with the type definition that allows string | null | undefined and the ACP protocol specification mentioned in issue #43 which uses null for new/deleted files.
| if (!oldText && !newText) { | |
| return []; | |
| } | |
| if (!oldText) { | |
| // New file - all lines are additions | |
| return newText!.split("\n").map((line) => ({ type: "add", line })); | |
| } | |
| if (!newText) { | |
| const oldIsAbsent = oldText === null || oldText === undefined; | |
| const newIsAbsent = newText === null || newText === undefined; | |
| if (oldIsAbsent && newIsAbsent) { | |
| return []; | |
| } | |
| if (oldIsAbsent) { | |
| // New file - all lines are additions | |
| return newText!.split("\n").map((line) => ({ type: "add", line })); | |
| } | |
| if (newIsAbsent) { |
| output = renderDiff( | ||
| firstContent.path, | ||
| firstContent.oldText, | ||
| firstContent.newText | ||
| ); |
There was a problem hiding this comment.
When renderDiff() output is assigned to tool.output (line 1004-1008), it will later be processed by getToolsHtml() which wraps all output in <pre class="tool-output"> tags (around line 360). Since renderDiff() already returns a complete HTML structure with <div class="diff-container">, this creates nested containers with semantic and styling issues.
The fix for the HTML escaping issue (comment #1) should also address this wrapping problem, likely by detecting when tool.output contains pre-rendered HTML and handling it differently in getToolsHtml().
| output = renderDiff( | ||
| firstContent.path, | ||
| firstContent.oldText, | ||
| firstContent.newText | ||
| ); |
There was a problem hiding this comment.
The HTML output from renderDiff() will be double-escaped when processed by getToolsHtml(). In getToolsHtml() at lines 354-357, if the output doesn't contain ANSI codes, it calls escapeHtml() on the output. Since diff HTML doesn't contain ANSI codes, it will be escaped, causing users to see raw HTML tags instead of the rendered diff.
To fix this, the code needs a way to indicate that tool.output contains pre-rendered HTML. Options include:
- Add a flag to the Tool interface (e.g.,
outputIsHtml: boolean) - Check for specific markers in the output string (e.g., check if output starts with
<div class="diff-container">) - Store the content type alongside the output and check it in getToolsHtml()
The same pattern is used for ANSI terminal output (ansiToHtml returns HTML), but that works because hasAnsiCodes() detects the ANSI escape sequences in the original output before it's converted.
| output = renderDiff( | |
| firstContent.path, | |
| firstContent.oldText, | |
| firstContent.newText | |
| ); | |
| const diffHtml = renderDiff( | |
| firstContent.path, | |
| firstContent.oldText, | |
| firstContent.newText | |
| ); | |
| // Convert HTML diff output to plain text to avoid double-escaping later. | |
| output = diffHtml.replace(/<[^>]*>/g, ""); |
| // Simple line-by-line diff | ||
| const oldLines = oldText.split("\n"); | ||
| const newLines = newText.split("\n"); | ||
| const result: DiffLine[] = []; | ||
|
|
||
| // Simple algorithm: mark old lines as removed, new lines as added | ||
| // Future optimization: detect common lines and mark as context | ||
| for (const line of oldLines) { | ||
| result.push({ type: "remove", line }); | ||
| } | ||
| for (const line of newLines) { | ||
| result.push({ type: "add", line }); | ||
| } | ||
|
|
There was a problem hiding this comment.
The current diff algorithm marks ALL old lines as removed and ALL new lines as added, which doesn't provide a useful diff visualization. For example, if only one line changes in a 100-line file, this will show 200 lines (100 deletions + 100 additions) instead of showing the single changed line with context.
While the comment at line 258 acknowledges this is a "simple algorithm" with "Future optimization" planned, this makes the diff feature almost unusable for real files. Even a basic longest common subsequence (LCS) algorithm would provide significantly better results by identifying unchanged lines to show as context.
Consider implementing at least a basic diff algorithm that identifies common lines, or document this limitation prominently in the PR description and user-facing documentation.
| // Simple line-by-line diff | |
| const oldLines = oldText.split("\n"); | |
| const newLines = newText.split("\n"); | |
| const result: DiffLine[] = []; | |
| // Simple algorithm: mark old lines as removed, new lines as added | |
| // Future optimization: detect common lines and mark as context | |
| for (const line of oldLines) { | |
| result.push({ type: "remove", line }); | |
| } | |
| for (const line of newLines) { | |
| result.push({ type: "add", line }); | |
| } | |
| // Diff based on longest common subsequence (LCS) of lines | |
| const oldLines = oldText.split("\n"); | |
| const newLines = newText.split("\n"); | |
| const m = oldLines.length; | |
| const n = newLines.length; | |
| // Build LCS length table | |
| const lcsLengths: number[][] = Array(m + 1) | |
| .fill(0) | |
| .map(() => Array(n + 1).fill(0)); | |
| for (let i = 1; i <= m; i++) { | |
| for (let j = 1; j <= n; j++) { | |
| if (oldLines[i - 1] === newLines[j - 1]) { | |
| lcsLengths[i][j] = lcsLengths[i - 1][j - 1] + 1; | |
| } else { | |
| lcsLengths[i][j] = Math.max(lcsLengths[i - 1][j], lcsLengths[i][j - 1]); | |
| } | |
| } | |
| } | |
| // Walk back through the table to build the diff | |
| const result: DiffLine[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 && j > 0) { | |
| if (oldLines[i - 1] === newLines[j - 1]) { | |
| result.push({ type: "context", line: oldLines[i - 1] }); | |
| i--; | |
| j--; | |
| } else if (lcsLengths[i - 1][j] >= lcsLengths[i][j - 1]) { | |
| result.push({ type: "remove", line: oldLines[i - 1] }); | |
| i--; | |
| } else { | |
| result.push({ type: "add", line: newLines[j - 1] }); | |
| j--; | |
| } | |
| } | |
| while (i > 0) { | |
| result.push({ type: "remove", line: oldLines[i - 1] }); | |
| i--; | |
| } | |
| while (j > 0) { | |
| result.push({ type: "add", line: newLines[j - 1] }); | |
| j--; | |
| } | |
| // We built the diff from the end to the start, so reverse it | |
| result.reverse(); |
| const truncated = diffLines.length > 500; | ||
| const linesToShow = truncated ? diffLines.slice(0, 500) : diffLines; | ||
|
|
||
| let html = '<div class="diff-container">'; | ||
|
|
||
| if (path) { | ||
| html += '<div class="diff-header">' + escapeHtml(path) + "</div>"; | ||
| } | ||
|
|
||
| html += '<pre class="diff-content">'; | ||
|
|
||
| for (const diffLine of linesToShow) { | ||
| const prefix = | ||
| diffLine.type === "add" ? "+ " : diffLine.type === "remove" ? "- " : " "; | ||
| const className = "diff-line diff-" + diffLine.type; | ||
| html += | ||
| '<div class="' + | ||
| className + | ||
| '">' + | ||
| escapeHtml(prefix + diffLine.line) + | ||
| "</div>"; | ||
| } | ||
|
|
||
| html += "</pre>"; | ||
|
|
||
| if (truncated) { | ||
| html += | ||
| '<div class="diff-truncated">... (truncated, showing first 500 of ' + | ||
| diffLines.length + | ||
| " lines)</div>"; | ||
| } |
There was a problem hiding this comment.
The diff rendering has its own truncation at 500 lines (line 280), but getToolsHtml() also truncates tool.output at 500 characters (line 351-352). This creates inconsistent behavior:
- For a diff with 600 lines, renderDiff() produces HTML for 500 lines + truncation message
- Then getToolsHtml() truncates this HTML string to 500 characters and appends "..."
- The result is broken HTML showing only a fragment of the diff container
This dual truncation needs to be reconciled. Either remove the character-based truncation for HTML output, or ensure the line-based truncation in renderDiff() produces output that stays well under the character limit.
| type: "add" | "remove" | "context"; | ||
| line: string; | ||
| } | ||
|
|
||
| /** | ||
| * Compute a simple line-by-line diff between old and new text. | ||
| * Returns an array of diff lines marked as add/remove/context. |
There was a problem hiding this comment.
The DiffLine interface includes a "context" type in the union (line 227), but this type is never actually used in computeLineDiff(). The function only ever creates diff lines with type "add" or "remove".
Either remove "context" from the type definition, or implement context line detection in the diff algorithm. The presence of unused type options suggests incomplete implementation and could confuse future maintainers.
| type: "add" | "remove" | "context"; | |
| line: string; | |
| } | |
| /** | |
| * Compute a simple line-by-line diff between old and new text. | |
| * Returns an array of diff lines marked as add/remove/context. | |
| type: "add" | "remove"; | |
| line: string; | |
| } | |
| /** | |
| * Compute a simple line-by-line diff between old and new text. | |
| * Returns an array of diff lines marked as add/remove. |
|
|
||
| suite("computeLineDiff", () => { | ||
| test("returns empty array for empty inputs", () => { | ||
| const result = computeLineDiff("", ""); |
There was a problem hiding this comment.
The test "returns empty array for empty inputs" passes empty strings to computeLineDiff, but the actual implementation treats empty strings as falsy and returns an empty array (line 240). However, empty strings ("") and null/undefined have different semantics - an empty string could represent an empty file, while null typically represents a non-existent file.
The current implementation conflates these cases: computeLineDiff("", "content") returns the same result as computeLineDiff(null, "content"), even though the first should show adding content to an existing empty file, while the second represents creating a new file.
Consider changing the logic to only check for null/undefined, not truthiness, to properly handle empty files.
| const result = computeLineDiff("", ""); | |
| const result = computeLineDiff(null, null); |
| suite("renderDiff", () => { | ||
| test("returns no changes message for empty diff", () => { | ||
| const result = renderDiff(undefined, "", ""); | ||
| assert.ok(result.includes("diff-container")); | ||
| assert.ok(result.includes("No changes")); | ||
| }); | ||
|
|
||
| test("renders file path header when provided", () => { | ||
| const result = renderDiff("/path/to/file.ts", null, "new content"); | ||
| assert.ok(result.includes("diff-header")); | ||
| assert.ok(result.includes("/path/to/file.ts")); | ||
| }); | ||
|
|
||
| test("renders additions with diff-add class", () => { | ||
| const result = renderDiff(undefined, null, "added line"); | ||
| assert.ok(result.includes("diff-add")); | ||
| assert.ok(result.includes("+ added line")); | ||
| }); | ||
|
|
||
| test("renders deletions with diff-remove class", () => { | ||
| const result = renderDiff(undefined, "removed line", null); | ||
| assert.ok(result.includes("diff-remove")); | ||
| assert.ok(result.includes("- removed line")); | ||
| }); | ||
|
|
||
| test("escapes HTML in diff content", () => { | ||
| const result = renderDiff( | ||
| undefined, | ||
| null, | ||
| "<script>alert('xss')</script>" | ||
| ); | ||
| assert.ok(result.includes("<script>")); | ||
| assert.ok(!result.includes("<script>alert")); | ||
| }); | ||
|
|
||
| test("truncates large diffs", () => { | ||
| const manyLines = Array(600).fill("line").join("\n"); | ||
| const result = renderDiff(undefined, null, manyLines); | ||
| assert.ok(result.includes("diff-truncated")); | ||
| assert.ok(result.includes("500")); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The test suite is missing integration tests that verify diff HTML output works correctly when passed through getToolsHtml(). The current tests only verify:
- renderDiff() produces HTML with the correct classes (tests at lines 1299-1340)
- getToolsHtml() handles ANSI terminal output (tests at lines 1103-1161)
But there's no test that verifies:
const tools = {
"tool-1": {
name: "edit_file",
output: renderDiff("/path/file.ts", "old", "new"),
...
}
};
const html = getToolsHtml(tools);
// Verify the diff HTML is rendered correctly, not escapedThis missing test would have caught the critical HTML escaping bug where diff output gets double-escaped.
The boulder plan for vscode-acp-feature-push is obsolete: - All tasks (plan view, FS, terminal) completed and merged - Plan view: PR #75 - FS/Terminal: PR #64 - Terminal ANSI: PR #76 - Thought chunks: PR #66 - Diff display: PR #83 Current state: - PR #84 ready to merge (all review threads resolved) - All beads tasks complete (0 open, 0 ready, 0 blocked) - Boulder state cleared
Summary
Add visual diff rendering for file edit operations in the chat view. When agents modify files, users now see a clear before/after comparison with syntax highlighting for additions and deletions.
Changes
computeLineDiff()for simple line-by-line diff computationrenderDiff()to generate styled HTML outputtoolCallCompletehandlerTesting
Screenshots
The diff view shows:
-prefix+prefixCloses #43